diff --git a/addons/web/models/__init__.py b/addons/web/models/__init__.py index 3dbae07edb1..4c4136a1a1e 100644 --- a/addons/web/models/__init__.py +++ b/addons/web/models/__init__.py @@ -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 diff --git a/addons/web/models/ir_model.py b/addons/web/models/ir_model.py new file mode 100644 index 00000000000..ade00f6ee89 --- /dev/null +++ b/addons/web/models/ir_model.py @@ -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) diff --git a/addons/web/static/src/core/model_selector/model_selector.js b/addons/web/static/src/core/model_selector/model_selector.js new file mode 100644 index 00000000000..3cbd81a7b6a --- /dev/null +++ b/addons/web/static/src/core/model_selector/model_selector.js @@ -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 }, +}; diff --git a/addons/web/static/src/core/model_selector/model_selector.xml b/addons/web/static/src/core/model_selector/model_selector.xml new file mode 100644 index 00000000000..cffc987108a --- /dev/null +++ b/addons/web/static/src/core/model_selector/model_selector.xml @@ -0,0 +1,21 @@ + + + +
+ + + +
+
+
diff --git a/addons/web/static/src/core/popover/popover_container.js b/addons/web/static/src/core/popover/popover_container.js index 16de55afb57..5c6b8234cf5 100644 --- a/addons/web/static/src/core/popover/popover_container.js +++ b/addons/web/static/src/core/popover/popover_container.js @@ -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(); } diff --git a/addons/web/static/src/core/popover/popover_hook.js b/addons/web/static/src/core/popover/popover_hook.js index 2b0dcdb6474..d41d5a95b2f 100644 --- a/addons/web/static/src/core/popover/popover_hook.js +++ b/addons/web/static/src/core/popover/popover_hook.js @@ -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()} diff --git a/addons/web/static/src/core/popover/popover_service.js b/addons/web/static/src/core/popover/popover_service.js index f30c1401e61..d21024518c5 100644 --- a/addons/web/static/src/core/popover/popover_service.js +++ b/addons/web/static/src/core/popover/popover_service.js @@ -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"); diff --git a/addons/web/static/src/legacy/js/fields/field_utils.js b/addons/web/static/src/legacy/js/fields/field_utils.js index 3c99114c8ff..96e96d6ce24 100644 --- a/addons/web/static/src/legacy/js/fields/field_utils.js +++ b/addons/web/static/src/legacy/js/fields/field_utils.js @@ -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, diff --git a/addons/web/static/src/views/basic_relational_model.js b/addons/web/static/src/views/basic_relational_model.js index 851480b5a33..5f25eb4aacc 100644 --- a/addons/web/static/src/views/basic_relational_model.js +++ b/addons/web/static/src/views/basic_relational_model.js @@ -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__; diff --git a/addons/web/static/src/views/fields/many2one/many2one_field.js b/addons/web/static/src/views/fields/many2one/many2one_field.js index 2f1797eac2f..3ba493f8c9f 100644 --- a/addons/web/static/src/views/fields/many2one/many2one_field.js +++ b/addons/web/static/src/views/fields/many2one/many2one_field.js @@ -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) { diff --git a/addons/web/static/src/views/fields/properties/icons/boolean.png b/addons/web/static/src/views/fields/properties/icons/boolean.png new file mode 100644 index 00000000000..717ca4f0963 Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/boolean.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/char.png b/addons/web/static/src/views/fields/properties/icons/char.png new file mode 100644 index 00000000000..9f8a9d102cd Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/char.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/date.png b/addons/web/static/src/views/fields/properties/icons/date.png new file mode 100644 index 00000000000..55c36fc0444 Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/date.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/datetime.png b/addons/web/static/src/views/fields/properties/icons/datetime.png new file mode 100644 index 00000000000..2314b7f3a39 Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/datetime.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/float.png b/addons/web/static/src/views/fields/properties/icons/float.png new file mode 100644 index 00000000000..f2beed07f4f Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/float.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/integer.png b/addons/web/static/src/views/fields/properties/icons/integer.png new file mode 100644 index 00000000000..6bf8bf86433 Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/integer.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/many2many.png b/addons/web/static/src/views/fields/properties/icons/many2many.png new file mode 100644 index 00000000000..0e2a006e316 Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/many2many.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/many2one.png b/addons/web/static/src/views/fields/properties/icons/many2one.png new file mode 100644 index 00000000000..c66bbbbbf4e Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/many2one.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/selection.png b/addons/web/static/src/views/fields/properties/icons/selection.png new file mode 100644 index 00000000000..8e38c27fad0 Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/selection.png differ diff --git a/addons/web/static/src/views/fields/properties/icons/tags.png b/addons/web/static/src/views/fields/properties/icons/tags.png new file mode 100644 index 00000000000..c26c3de9405 Binary files /dev/null and b/addons/web/static/src/views/fields/properties/icons/tags.png differ diff --git a/addons/web/static/src/views/fields/properties/properties_field.js b/addons/web/static/src/views/fields/properties/properties_field.js new file mode 100644 index 00000000000..364022f67ae --- /dev/null +++ b/addons/web/static/src/views/fields/properties/properties_field.js @@ -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); diff --git a/addons/web/static/src/views/fields/properties/properties_field.scss b/addons/web/static/src/views/fields/properties/properties_field.scss new file mode 100644 index 00000000000..1ea5f54585e --- /dev/null +++ b/addons/web/static/src/views/fields/properties/properties_field.scss @@ -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; + } +} diff --git a/addons/web/static/src/views/fields/properties/properties_field.xml b/addons/web/static/src/views/fields/properties/properties_field.xml new file mode 100644 index 00000000000..86a51e62b56 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/properties_field.xml @@ -0,0 +1,57 @@ + + + +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
diff --git a/addons/web/static/src/views/fields/properties/property_definition.js b/addons/web/static/src/views/fields/properties/property_definition.js new file mode 100644 index 00000000000..fff986fb13d --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.js @@ -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 }, +}; diff --git a/addons/web/static/src/views/fields/properties/property_definition.scss b/addons/web/static/src/views/fields/properties/property_definition.scss new file mode 100644 index 00000000000..96e34dd3c1e --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.scss @@ -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; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_definition.xml b/addons/web/static/src/views/fields/properties/property_definition.xml new file mode 100644 index 00000000000..da3bcb1716a --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.xml @@ -0,0 +1,169 @@ + + + +
+
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Field Type + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+
+
+ Values + + +
+ Tags + + +
+ Model + + + + + +
+ Domain + + +
+ + +
+
+ + Default State + + + Default Value + + + +
+
+
+
diff --git a/addons/web/static/src/views/fields/properties/property_definition_selection.js b/addons/web/static/src/views/fields/properties/property_definition_selection.js new file mode 100644 index 00000000000..d3ac8cc6d9c --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition_selection.js @@ -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 +}; diff --git a/addons/web/static/src/views/fields/properties/property_definition_selection.scss b/addons/web/static/src/views/fields/properties/property_definition_selection.scss new file mode 100644 index 00000000000..7bfdbea18f4 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition_selection.scss @@ -0,0 +1,10 @@ +.o_field_property_selection { + input { + border: 0; + background: transparent; + } + .fa-star, + .fa-star-o { + color: gold; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_definition_selection.xml b/addons/web/static/src/views/fields/properties/property_definition_selection.xml new file mode 100644 index 00000000000..1c52bddecab --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition_selection.xml @@ -0,0 +1,46 @@ + + + +
+
+
+ + + + +
+ +
+
+
+
diff --git a/addons/web/static/src/views/fields/properties/property_tags.js b/addons/web/static/src/views/fields/properties/property_tags.js new file mode 100644 index 00000000000..07364289065 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_tags.js @@ -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 }, +}; diff --git a/addons/web/static/src/views/fields/properties/property_tags.scss b/addons/web/static/src/views/fields/properties/property_tags.scss new file mode 100644 index 00000000000..72b9241f2b0 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_tags.scss @@ -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; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_tags.xml b/addons/web/static/src/views/fields/properties/property_tags.xml new file mode 100644 index 00000000000..4fbec6cfbc1 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_tags.xml @@ -0,0 +1,27 @@ + + + +
+ + +
+ + +
+ +
+
+ diff --git a/addons/web/static/src/views/fields/properties/property_value.js b/addons/web/static/src/views/fields/properties/property_value.js new file mode 100644 index 00000000000..38e4d629062 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_value.js @@ -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 }, +}; diff --git a/addons/web/static/src/views/fields/properties/property_value.scss b/addons/web/static/src/views/fields/properties/property_value.scss new file mode 100644 index 00000000000..32f6cb4ab22 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_value.scss @@ -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; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_value.xml b/addons/web/static/src/views/fields/properties/property_value.xml new file mode 100644 index 00000000000..8c819c90ee3 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_value.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + +
+
+ + + + + + diff --git a/addons/web/static/src/views/fields/relational_utils.js b/addons/web/static/src/views/fields/relational_utils.js index a175ea72f5b..5a07252ff00 100644 --- a/addons/web/static/src/views/fields/relational_utils.js +++ b/addons/web/static/src/views/fields/relational_utils.js @@ -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, diff --git a/addons/web/static/src/views/utils.js b/addons/web/static/src/views/utils.js index a1295c68205..c95c041fc0a 100644 --- a/addons/web/static/src/views/utils.js +++ b/addons/web/static/src/views/utils.js @@ -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(''); +} diff --git a/addons/web/static/tests/core/model_selector_test.js b/addons/web/static/tests/core/model_selector_test.js new file mode 100644 index 00000000000..a2e6ee4fb30 --- /dev/null +++ b/addons/web/static/tests/core/model_selector_test.js @@ -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"); +}); diff --git a/addons/web/static/tests/views/fields/properties_field_tests.js b/addons/web/static/tests/views/fields/properties_field_tests.js new file mode 100644 index 00000000000..d5cf8167a02 --- /dev/null +++ b/addons/web/static/tests/views/fields/properties_field_tests.js @@ -0,0 +1,1103 @@ +/** @odoo-module **/ + +import { + click, + editInput, + getFixture, + nextTick, + triggerEvent, +} from "@web/../tests/helpers/utils"; +import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; + +let serverData; +let target; + +async function closePopover(target) { + // Close the popover by clicking outside + document.activeElement.blur(); + await click(document, "html"); +} + +QUnit.module("Fields", (hooks) => { + hooks.beforeEach(() => { + target = getFixture(); + serverData = { + models: { + partner: { + fields: { + properties: { + string: "Properties", + type: "properties", + searchable: false, + definition_record: "company_id", + }, + company_id: { + string: "Company", + type: "many2one", + relation: "company", + }, + }, + records: [ + { + id: 1, + display_name: "first partner", + properties: [ + { + name: "property_1", + string: "My Char", + type: "char", + value: "char value", + }, + { + name: "property_2", + string: "My Selection", + type: "selection", + selection: [ + ["a", "A"], + ["b", "B"], + ["c", "C"], + ], + value: "b", + default: "c", + }, + ], + company_id: 37, + }, + { + id: 2, + display_name: "second partner", + properties: [ + { + name: "property_1", + string: "My Char", + type: "char", + value: "char value", + }, + { + name: "property_2", + string: "My Selection", + type: "selection", + selection: [ + ["a", "A"], + ["b", "B"], + ["c", "C"], + ], + value: "c", + default: "c", + }, + { + name: "property_3", + string: "My Char 3", + type: "char", + value: "char value 3", + }, + { + name: "property_4", + string: "My Char 4", + type: "char", + value: "char value 4", + }, + ], + company_id: 37, + }, + ], + }, + company: { + fields: { + name: { + string: "Name", + type: "char", + }, + }, + records: [ + { + id: 37, + display_name: "Company 1", + }, + ], + }, + }, + }; + + setupViewRegistries(); + }); + + QUnit.module("PropertiesField"); + + /** + * If the current user can not write on the parent, he should not + * be able to change the properties definition (but he should be able to + * change the properties value). + */ + QUnit.test("properties: no access to parent", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return false; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const addButton = target.querySelector(".o_field_property_add button"); + assert.notOk(addButton, "The add button must not be in the view"); + + const editButton = field.querySelector( + ".o_field_property_open_popover" + ); + assert.notOk( + editButton, + "The edit definition button must not be in the view" + ); + + const property = field.querySelector(".o_property_field_value input"); + assert.strictEqual(property.value, "char value"); + }); + + /** + * If the current user can write on the parent, he should + * be able to change the properties definition. + */ + QUnit.test("properties: access to parent", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const addButton = target.querySelector(".o_field_property_add button"); + assert.ok(addButton, "The add button must be in the view"); + + const editButton = field.querySelectorAll( + ".o_field_property_open_popover" + ); + assert.ok(editButton, "The edit definition button must be in the view"); + + const property = field.querySelector(".o_property_field_value input"); + assert.strictEqual(property.value, "char value"); + + // Open the definition popover + await click( + target, + ".o_property_field:first-child .o_field_property_open_popover" + ); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + const label = popover.querySelector( + ".o_field_property_definition_header input" + ); + assert.strictEqual(label.value, "My Char"); + + const type = popover.querySelector( + ".o_field_property_definition_type input" + ); + assert.strictEqual(type.value, "Text"); + + // Change the property type to "Date & Time" + await editInput( + target, + ".o_field_property_definition_header input", + "My Datetime" + ); + await click(target, ".o_field_property_definition_type button"); + await click( + target, + ".o_field_property_definition_type .dropdown-menu .dropdown-item:nth-child(6)" + ); + assert.strictEqual( + type.value, + "Date & Time", + "Should have changed the property type" + ); + + // Choosing a date in the date picker should not close the definition popover + await click( + target, + ".o_field_property_definition_value .o_datepicker_input" + ); + await click( + document, + ".datepicker-days tr:first-child .day:nth-child(3)" + ); + assert.ok( + document.querySelector(".picker-switch .fa-check"), + "Should not close the definition popover after selecting a date" + ); + await click(document, ".picker-switch .fa-check"); + assert.ok( + target.querySelector(".o_property_field_popover"), + "Should not close the definition popover after selecting a date" + ); + + await closePopover(target); + + // Check that the type change have been propagated + const datetimeLabel = field.querySelector(".o_field_property_label b"); + assert.strictEqual( + datetimeLabel.innerText, + "My Datetime", + "Should have updated the property label" + ); + const datetimeComponent = field.querySelector( + ".o_property_field_value .o_datepicker" + ); + assert.ok(datetimeComponent, "Should have changed the property type"); + + // Check that the value is reset (because the type changed) + const inputValue = document.querySelector( + ".o_property_field_value input" + ); + assert.notOk(inputValue.value); + + // Discard the form view and check that the properties take its old values + await click(target, ".o_form_button_cancel"); + const propertyValue = document.querySelector( + ".o_property_field:first-child .o_property_field_value" + ); + assert.strictEqual( + propertyValue.innerText, + "char value", + "Discarding the form view should reset the old values" + ); + }); + + /** + * Test the creation of a new property. + */ + QUnit.test("properties: add a new property", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const addButton = target.querySelector(".o_field_property_add button"); + assert.ok(addButton, "The add button must be in the view"); + + // Create a new property + await click(target, ".o_field_property_add button"); + + await nextTick(); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + const label = popover.querySelector( + ".o_field_property_definition_header input" + ); + assert.strictEqual( + label.value, + "Property 3", + "Should have added a default label" + ); + + const type = popover.querySelector( + ".o_field_property_definition_type input" + ); + assert.strictEqual(type.value, "Text", "Default type must be text"); + + await closePopover(target); + + const properties = field.querySelectorAll(".o_property_field"); + assert.strictEqual(properties.length, 3); + + const newProperty = properties[2]; + const newPropertyLabel = newProperty.querySelector( + ".o_field_property_label" + ); + assert.strictEqual(newPropertyLabel.innerText, "Property 3"); + }); + + /** + * Test the selection property. + */ + QUnit.test("properties: selection", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const selectionValue = target.querySelector( + ".o_property_field:nth-child(2) select" + ); + assert.ok(selectionValue); + assert.strictEqual(selectionValue.value, "b"); + + // Edit the selection property + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + const selectionComponent = popover.querySelector( + ".o_field_property_selection" + ); + assert.ok( + selectionComponent, + "Must instantiate the selection component" + ); + + // Check the default option + const option1 = popover.querySelector( + ".o_field_property_selection_option:nth-child(1)" + ); + assert.notOk( + option1.querySelector(".fa-star"), + "Default option must be the third one" + ); + const option2 = popover.querySelector( + ".o_field_property_selection_option:nth-child(2)" + ); + assert.notOk( + option2.querySelector(".fa-star"), + "Default option must be the third one" + ); + const option3 = popover.querySelector( + ".o_field_property_selection_option:nth-child(3)" + ); + assert.ok(option3); + assert.ok( + option3.querySelector(".fa-star"), + "Default option must be the third one" + ); + + const type = popover.querySelector( + ".o_field_property_definition_type input" + ); + assert.strictEqual(type.value, "Selection"); + + // Create a new selection option + await click(target, ".o_field_property_selection .fa-plus"); + let options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 4, + "Should have added the new option" + ); + assert.strictEqual( + document.activeElement, + options[3].querySelector("input"), + "Should focus the new option" + ); + + await editInput( + target, + ".o_field_property_selection_option:nth-child(4) input", + "New option" + ); + + // Press enter to add a second new option + await triggerEvent( + target, + ".o_field_property_selection_option:nth-child(4) input", + "keydown", + { key: "Enter" } + ); + await nextTick(); + + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 5, + "Should have added the new option on Enter" + ); + assert.strictEqual( + document.activeElement, + options[4].querySelector("input"), + "Should focus the new option" + ); + + // Up arrow should give the focus to the previous option + // because the new option is empty and lost focus, it should be removed + await triggerEvent( + target, + ".o_field_property_selection_option:nth-child(5) input", + "keydown", + { key: "ArrowUp" } + ); + + await nextTick(); + + assert.strictEqual( + document.activeElement, + options[3].querySelector("input"), + "Should focus the previous option" + ); + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 4, + "Should have remove the option because it is empty and lost focus" + ); + + await nextTick(); + + // Up again, should focus the previous option + await triggerEvent( + target, + ".o_field_property_selection_option:nth-child(4) input", + "keydown", + { key: "ArrowUp" } + ); + await nextTick(); + + assert.strictEqual( + document.activeElement, + options[2].querySelector("input") + ); + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual(options.length, 4, "Should not remove any options"); + + // Remove the second option + await click( + target, + ".o_field_property_selection_option:nth-child(2) .fa-times" + ); + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 3, + "Should have removed the second option" + ); + const optionValues = [...options].map( + (option) => option.querySelector("input").value + ); + assert.deepEqual( + optionValues, + ["A", "C", "New option"], + "Should have removed the second option" + ); + }); + + /** + * Test the properties re-arrangement + */ + QUnit.test("properties: move properties", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + // Return the properties labels + const getLabels = () => { + const labels = target.querySelectorAll( + ".o_field_properties .o_field_property_label" + ); + return [...labels].map((label) => label.innerText); + }; + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + // Edit the selection property + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + // Move the property up + await click(popover, ".fa-chevron-up"); + assert.deepEqual( + getLabels(), + ["My Selection", "My Char", "My Char 3", "My Char 4"], + "Should have moved up the property" + ); + const movedProperty = target.querySelector( + ".o_property_field:nth-child(1) .o_property_field_highlight" + ); + assert.ok(movedProperty, "Should highlight the moved property"); + + // Move the property up again, should have no effect + await click(popover, ".fa-chevron-up"); + assert.deepEqual(getLabels(), [ + "My Selection", + "My Char", + "My Char 3", + "My Char 4", + ]); + + // Move the property down + await click(popover, ".fa-chevron-down"); + assert.deepEqual(getLabels(), [ + "My Char", + "My Selection", + "My Char 3", + "My Char 4", + ]); + + // Move the property at the bottom + await click(popover, ".fa-chevron-down"); + await click(popover, ".fa-chevron-down"); + assert.deepEqual(getLabels(), [ + "My Char", + "My Char 3", + "My Char 4", + "My Selection", + ]); + + await closePopover(target); + + const highlightProperty = target.querySelector( + ".o_property_field:nth-child(2) .o_property_field_highlight" + ); + assert.notOk(highlightProperty, "Should have removed the highlight"); + }); + + /** + * Test the properties tags + */ + QUnit.test("properties: tags", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + const createNewTag = async (selector, text) => { + await click(target, selector); + await editInput(target, selector, text); + for (let i = 0; i < 50; ++i) { + // Wait until the dropdown appears + await nextTick(); + } + await triggerEvent(target, selector, "keydown", { key: "Enter" }); + await nextTick(); + }; + + const getVisibleTags = (target, selector) => { + const tags = target.querySelectorAll(selector); + return [...tags].map((tag) => tag.innerText); + }; + + await click(target, ".o_form_button_edit"); + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + let popover = target.querySelector(".o_property_field_popover"); + // Select the tags type + await click(popover, ".o_field_property_definition_type input"); + await nextTick(); + await click( + popover, + ".o_field_property_definition_type .dropdown-item:nth-child(8)" + ); + + // Create 3 tags + const tagsInputSelector = + ".o_property_field_popover .o_field_property_dropdown_menu input"; + await createNewTag(tagsInputSelector, "A"); + await createNewTag(tagsInputSelector, "B"); + await createNewTag(tagsInputSelector, "C"); + assert.deepEqual(getVisibleTags(popover, ".o_tag"), ["A", "B", "C"]); + + await closePopover(target); + + // Edit the tags value + const tagsComponent = target.querySelector( + ".o_property_field_value .o_input_dropdown" + ); + await click(target, ".o_property_field_value .o_input_dropdown input"); + + // Check that he newly created tags are available + assert.deepEqual( + getVisibleTags(tagsComponent, ".dropdown-item"), + ["A", "B", "C"], + "Should be able to selected the created tags" + ); + + // Select one tag in the list + await click( + target, + ".o_property_field_value .ui-menu-item:nth-child(2)" + ); + assert.deepEqual( + getVisibleTags(target, ".o_property_field_value .o_tag"), + ["B"], + "Should have selected the tag B" + ); + + // Re-open the tag dropdown and verify that the selected tag is not in the list + // (because it's already selected) + await click(target, ".o_property_field_value .o_input_dropdown input"); + assert.deepEqual( + getVisibleTags(tagsComponent, ".dropdown-item"), + ["A", "C"], + "The tag B is already selected and should not be visible in the dropdown" + ); + + // Create a new tag from the property value component + await createNewTag( + ".o_property_field_value .o_field_property_dropdown_menu input", + "D" + ); + assert.deepEqual( + getVisibleTags(target, ".o_property_field_value .o_tag"), + ["B", "D"], + "Should have created and selected the tag D" + ); + + // Re-open the popover and check that the new tag has been added in the definition + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + await nextTick(); + popover = target.querySelector(".o_property_field_popover"); + assert.deepEqual(getVisibleTags(popover, ".o_tag"), [ + "A", + "B", + "C", + "D", + ]); + + // Change the tag color + await click(popover, ".o_tag:nth-child(2)"); + await click(target, ".o_tag_popover .o_colorlist_item_color_11"); + let secondTag = popover.querySelector(".o_tag:nth-child(2)"); + assert.ok( + secondTag.classList.contains("o_tag_color_11"), + "Should have changed the tag color" + ); + + // Check that the new B color has been propagated in the form view + await closePopover(target); + secondTag = target.querySelector( + ".o_property_field_value .o_tag:first-child" + ); + assert.ok( + secondTag.classList.contains("o_tag_color_11"), + "Should have changed the tag color" + ); + + // Open the popover and remove B from the definition + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + await click( + target, + ".o_property_field_popover .o_tag:nth-child(2) .o_delete" + ); + await closePopover(); + const tags = target.querySelectorAll(".o_property_field_value .o_tag"); + assert.strictEqual( + tags.length, + 1, + "Should have unselected the removed tag B" + ); + }); + + /** + * Test the properties many2one + */ + QUnit.test("properties: many2one", async function (assert) { + async function mockRPC(route, { method, model, args, kwargs }) { + if (method === "check_access_rights") { + return true; + } else if ( + method === "get_available_models" && + model === "ir.model" + ) { + return [ + { model: "res.partner", display_name: "Partner" }, + { model: "res.users", display_name: "User" }, + ]; + } else if (method === "name_search" && model === "res.users") { + return [ + [1, "Alice"], + [2, "Bob"], + [3, "Eve"], + ]; + } else if (method === "name_create" && model === "res.users") { + // Add a prefix to check that "name_create" + // has been called with the right parameters + return [1234, "Created:" + args[0]]; + } else if (method === "fields_get" && model === "res.users") { + return { + name: { searchable: true, string: "Name", type: "char" }, + login: { searchable: true, string: "Name", type: "char" }, + }; + } else if (method === "search_count" && model === "res.users") { + return 5; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + let popover = target.querySelector(".o_property_field_popover"); + // Select the many2one type + await click(popover, ".o_field_property_definition_type input"); + await nextTick(); + await click( + popover, + ".o_field_property_definition_type .dropdown-item:nth-child(9)" + ); + + // Choose the "User" model + await click(popover, ".o_field_property_definition_model input"); + let models = target.querySelectorAll( + ".o_field_property_definition_model .ui-menu-item" + ); + models = [...models].map((model) => model.innerText); + assert.deepEqual(models, ["Partner", "User"]); + await click( + popover, + ".o_field_property_definition_model .ui-menu-item:nth-child(2)" + ); + + const selectedModel = target.querySelector( + ".o_field_property_definition_model input" + ); + assert.strictEqual( + selectedModel.value, + "User", + "Should have selected the User model" + ); + + // Choose a many2one value + await click(popover, ".o_field_property_definition_value input"); + await click( + popover, + ".o_field_property_definition_value .ui-menu-item:nth-child(3)" + ); + let selectedUser = target.querySelector( + ".o_field_property_definition_value input" + ); + assert.strictEqual( + selectedUser.value, + "Eve", + "Should have selected the third user" + ); + + await closePopover(target); + + // Quick create a user + await click( + target, + ".o_property_field:nth-child(2) .o_property_field_value input" + ); + await editInput( + target, + ".o_property_field:nth-child(2) input", + "New User" + ); + for (let i = 0; i < 50; ++i) await nextTick(); // wait until the dropdown appears + await click( + target, + ".o_property_field:nth-child(2) .o_m2o_dropdown_option_create" + ); + selectedUser = target.querySelector( + ".o_property_field:nth-child(2) .o_property_field_value input" + ); + assert.strictEqual( + selectedUser.value, + "Created:New User", + "Should have created a new user" + ); + }); + + /** + * Test the properties many2many + * FIXME: broken by mighty TDE + */ + QUnit.skip("properties: many2many", async function (assert) { + async function mockRPC(route, { method, model, args, kwargs }) { + if (method === "check_access_rights") { + return true; + } else if ( + method === "get_available_models" && + model === "ir.model" + ) { + return [ + { model: "res.partner", display_name: "Partner" }, + { model: "res.users", display_name: "User" }, + ]; + } else if (method === "name_create" && model === "res.users") { + // Add a prefix to check that "name_create" + // has been called with the right parameters + return [1234, "Created:" + args[0]]; + } else if (method === "search_count" && model === "res.users") { + return 5; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + const getSelectedUsers = () => { + const selectedUsers = target.querySelectorAll( + ".o_property_field_value .o_tag_badge_text" + ); + return [...selectedUsers].map((badge) => badge.innerText); + }; + + await click(target, ".o_form_button_edit"); + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + let popover = target.querySelector(".o_property_field_popover"); + // Select the many2many type + await click(popover, ".o_field_property_definition_type input"); + await nextTick(); + await click( + popover, + ".o_field_property_definition_type .dropdown-item:nth-child(10)" + ); + + // Choose the "User" model + await click(popover, ".o_field_property_definition_model input"); + let models = target.querySelectorAll( + ".o_field_property_definition_model .ui-menu-item" + ); + models = [...models].map((model) => model.innerText); + assert.deepEqual(models, ["Partner", "User"]); + await click( + popover, + ".o_field_property_definition_model .ui-menu-item:nth-child(2)" + ); + + const selectedModel = target.querySelector( + ".o_field_property_definition_model input" + ); + assert.strictEqual( + selectedModel.value, + "User", + "Should have selected the User model" + ); + + await closePopover(target); + + // Add Eve in the list + await click(target, ".o_property_field:nth-child(2) input"); + await click( + target, + ".o_property_field:nth-child(2) .ui-menu-item:nth-child(3)" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve"], + "Should have selected the third user" + ); + + // Add Bob in the list + await click(target, ".o_property_field:nth-child(2) input"); + await click( + target, + ".o_property_field:nth-child(2) .ui-menu-item:nth-child(2)" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve", "Bob"], + "Should have selected the second user" + ); + + // Quick create a user + await click( + target, + ".o_property_field:nth-child(2) .o_property_field_value input" + ); + await editInput( + target, + ".o_property_field:nth-child(2) input", + "New User" + ); + for (let i = 0; i < 50; ++i) await nextTick(); // wait until the dropdown appears + await click( + target, + ".o_property_field:nth-child(2) .o_m2o_dropdown_option_create" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve", "Bob", "Created:New User"], + "Should have created a new user" + ); + + // Remove Bob from the list + await click( + target, + ".o_property_field:nth-child(2) .o_tag:nth-child(2) .o_delete" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve", "Created:New User"], + "Should have removed Bob from the list" + ); + }); +}); diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index d2330ddc32f..35229ab1a12 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -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 diff --git a/addons/web/tests/test_ir_model.py b/addons/web/tests/test_ir_model.py new file mode 100644 index 00000000000..8ba412761cb --- /dev/null +++ b/addons/web/tests/test_ir_model.py @@ -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"}]) diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py index d22e99e13a9..3185b6723b3 100644 --- a/odoo/addons/test_new_api/models/test_new_api.py +++ b/odoo/addons/test_new_api/models/test_new_api.py @@ -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. diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv index da38b7734ef..0b4bd42086c 100644 --- a/odoo/addons/test_new_api/security/ir.model.access.csv +++ b/odoo/addons/test_new_api/security/ir.model.access.csv @@ -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 diff --git a/odoo/addons/test_new_api/tests/__init__.py b/odoo/addons/test_new_api/tests/__init__.py index e85dbab13e2..8b9033c0250 100644 --- a/odoo/addons/test_new_api/tests/__init__.py +++ b/odoo/addons/test_new_api/tests/__init__.py @@ -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 diff --git a/odoo/addons/test_new_api/tests/test_properties.py b/odoo/addons/test_new_api/tests/test_properties.py new file mode 100644 index 00000000000..ab552b9e2a5 --- /dev/null +++ b/odoo/addons/test_new_api/tests/test_properties.py @@ -0,0 +1,1238 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from unittest.mock import patch + +from odoo import Command +from odoo.exceptions import AccessError +from odoo.tests.common import Form, TransactionCase +from odoo.tools import mute_logger + + +class PropertiesCase(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user = cls.env.user + cls.partner = cls.env['test_new_api.partner'].create({'name': 'Test Partner Properties'}) + cls.partner_2 = cls.env['test_new_api.partner'].create({'name': 'Test Partner Properties 2'}) + + attributes_definition_1 = [{ + 'name': 'discussion_color_code', + 'string': 'Color Code', + 'type': 'char', + 'default': 'blue', + }, { + 'name': 'moderator_partner_id', + 'string': 'Partner', + 'type': 'many2one', + 'comodel': 'test_new_api.partner', + }] + + attributes_definition_2 = [{ + 'name': 'state', + 'type': 'selection', + 'string': 'Status', + 'selection': [('draft', 'Draft'), ('progress', 'In Progress'), ('done', 'Done')], + 'default': 'draft', + }] + + cls.discussion_1 = cls.env['test_new_api.discussion'].create({ + 'name': 'Test Discussion', + 'attributes_definition': attributes_definition_1, + 'participants': [Command.link(cls.user.id)], + }) + cls.discussion_2 = cls.env['test_new_api.discussion'].create({ + 'name': 'Test Discussion', + 'attributes_definition': attributes_definition_2, + 'participants': [Command.link(cls.user.id)], + }) + + cls.message_1 = cls.env['test_new_api.message'].create({ + 'name': 'Test Message', + 'discussion': cls.discussion_1.id, + 'author': cls.user.id, + 'attributes': { + 'discussion_color_code': 'Test', + 'moderator_partner_id': cls.partner.id, + }, + }) + + cls.message_2 = cls.env['test_new_api.message'].create({ + 'name': 'Test Message', + 'discussion': cls.discussion_1.id, + 'author': cls.user.id, + }) + cls.message_3 = cls.env['test_new_api.message'].create({ + 'name': 'Test Message', + 'discussion': cls.discussion_2.id, + 'author': cls.user.id, + }) + + def test_properties_field(self): + self.assertTrue(isinstance(self.message_1.attributes, list)) + # testing assigned value + self.assertEqual(self.message_1.attributes, [{ + 'name': 'discussion_color_code', + 'string': 'Color Code', + 'type': 'char', + 'default': 'blue', + 'value': 'Test', + }, { + 'name': 'moderator_partner_id', + 'string': 'Partner', + 'type': 'many2one', + 'comodel': 'test_new_api.partner', + 'value': self.partner.id, + }]) + + self.assertEqual(self.message_2.attributes[0]['value'], 'blue') + self.assertFalse(self.message_2.attributes[1]['value']) + # testing default value + self.assertEqual( + self.message_3.attributes[0]['value'], 'draft', + msg='Should have taken the default value') + + self.message_1.attributes = [ + {'name': 'discussion_color_code', 'value': 'red'}, + {'name': 'moderator_partner_id', 'value': self.partner_2.id}, + ] + self.assertEqual(self.message_1.attributes[0]['value'], 'red') + + self.env.invalidate_all() + + self.assertEqual(self.message_1.attributes[0]['value'], 'red') + self.assertEqual(self.message_1.attributes[1]['value'], self.partner_2.id) + + # check that the value has been updated in the database + database_values = self._get_sql_properties(self.message_1) + self.assertTrue(isinstance(database_values, dict)) + self.assertEqual( + database_values.get('discussion_color_code'), 'red', + msg='Value must be updated in the database') + + # if we write False on the field, it should still + # return the properties definition for the web client + self.message_3.attributes = False + self.env.invalidate_all() + + expected = self.discussion_2.attributes_definition + for property_definition in expected: + property_definition['value'] = None + + self.assertEqual(self.message_3.read(['attributes'])[0]['attributes'], expected) + self.assertEqual(self.message_3.attributes, expected) + + def test_properties_field_write_batch(self): + """Test the behavior of the write called in batch. + + Simulate a write operation done by the web client. + """ + # mix both properties + properties_values = (self.message_1 | self.message_3).read(['attributes']) + properties_values = properties_values[0]['attributes'] + properties_values[1]['attributes'] + + for properties in properties_values: + if properties['name'] == 'discussion_color_code': + properties['value'] = 'orange' + elif properties['name'] == 'state': + properties['value'] = 'done' + elif properties['name'] == 'moderator_partner_id': + properties['value'] = self.partner_2.id + properties['definition_changed'] = True + + (self.message_1 | self.message_3).write({'attributes': properties_values}) + + sql_values_1 = self._get_sql_properties(self.message_1) + sql_values_3 = self._get_sql_properties(self.message_3) + + # definition of both child has been changed + 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'}) + + def test_properties_field_read_batch(self): + values = self.message_1.read(['attributes'])[0]['attributes'] + self.assertEqual(len(values), 2) + self.assertEqual(values[0]['type'], 'char') + self.assertEqual(values[1]['type'], 'many2one') + + message_2_values = self.message_1.attributes + message_2_values[1]['value'] = [self.partner_2.id, "Bob"] + self.message_2.attributes = message_2_values + + expected_queries = [ + # read the properties field value + 'SELECT "test_new_api_message"."id" AS "id", "test_new_api_message"."attributes" AS "attributes" FROM "test_new_api_message" WHERE "test_new_api_message".id IN %s', + 'SELECT "test_new_api_message"."id" AS "id", "test_new_api_message"."discussion" AS "discussion", "test_new_api_message"."body" AS "body", "test_new_api_message"."author" AS "author", "test_new_api_message"."name" AS "name", "test_new_api_message"."important" AS "important", "test_new_api_message"."priority" AS "priority", "test_new_api_message"."create_uid" AS "create_uid", "test_new_api_message"."create_date" AS "create_date", "test_new_api_message"."write_uid" AS "write_uid", "test_new_api_message"."write_date" AS "write_date" FROM "test_new_api_message" WHERE "test_new_api_message".id IN %s', + # read the definition on the definition record + 'SELECT "test_new_api_discussion"."id" AS "id", "test_new_api_discussion"."attributes_definition" AS "attributes_definition" FROM "test_new_api_discussion" WHERE "test_new_api_discussion".id IN %s', + # check the many2one existence + 'SELECT "test_new_api_partner".id FROM "test_new_api_partner" WHERE "test_new_api_partner".id IN %s', + 'SELECT "test_new_api_partner"."id" AS "id", "test_new_api_partner"."name" AS "name", "test_new_api_partner"."create_uid" AS "create_uid", "test_new_api_partner"."create_date" AS "create_date", "test_new_api_partner"."write_uid" AS "write_uid", "test_new_api_partner"."write_date" AS "write_date" FROM "test_new_api_partner" WHERE "test_new_api_partner".id IN %s', + ] + + self.env.invalidate_all() + with self.assertQueryCount(5), self.assertQueries(expected_queries): + self.message_1.read(['attributes']) + + 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']) + + def test_properties_field_delete(self): + """Test to delete a property using the flag "definition_deleted".""" + self.message_1.attributes = [{ + 'name': 'discussion_color_code', + 'string': 'Test color code', + 'type': 'char', + 'default': 'blue', + 'value': 'purple', + }, { + 'name': 'moderator_partner_id', + 'string': 'Partner', + 'type': 'many2one', + 'comodel': 'test_new_api.partner', + 'value': [self.partner.id, 'Bob'], + 'definition_deleted': True, + }] + + sql_definition = self._get_sql_definition(self.discussion_1) + self.assertEqual( + sql_definition, [{ + 'name': 'discussion_color_code', + 'type': 'char', + 'string': 'Test color code', + 'default': 'blue', + }]) + + self.assertEqual(len(self.message_1.attributes), 1) + self.assertEqual(self.message_1.attributes[0]['value'], 'purple') + + def test_properties_field_create_batch(self): + # first create to cache the access rights + self.env['test_new_api.message'].create({'name': 'test'}) + + with self.assertQueryCount(2): + messages = self.env['test_new_api.message'].create([{ + 'name': 'Test Message', + 'discussion': False, + 'author': self.user.id, + }, { + 'name': 'Test Message', + 'discussion': False, + 'author': self.user.id, + }]) + self.env.invalidate_all() + + with self.assertQueryCount(9): + messages = self.env['test_new_api.message'].create([{ + 'name': 'Test Message', + 'discussion': self.discussion_1.id, + 'author': self.user.id, + 'attributes': [{ + 'name': 'discussion_color_code', + 'string': 'New Label', + 'type': 'char', + 'default': 'blue', + 'value': 'purple', + }, { + 'name': 'moderator_partner_id', + 'string': 'Partner', + 'type': 'many2one', + 'comodel': 'test_new_api.partner', + 'value': self.partner.id, + 'definition_changed': True, + }], + }, { + 'name': 'Test Message', + 'discussion': self.discussion_2.id, + 'author': self.user.id, + 'attributes': [{ + 'name': 'state', + 'type': 'selection', + 'string': 'Status', + 'selection': [ + ('draft', 'Draft'), + ('progress', 'In Progress'), + ('done', 'Done'), + ], + 'default': 'draft', + 'definition_changed': True, + }], + }]) + self.env.invalidate_all() + + sql_definition = self._get_sql_definition(self.discussion_1) + self.assertEqual(sql_definition, [ + { + 'name': 'discussion_color_code', + 'type': 'char', 'string': + 'New Label', 'default': + 'blue' + }, { + 'name': 'moderator_partner_id', + 'type': 'many2one', + 'comodel': 'test_new_api.partner', + 'string': 'Partner', + } + ]) + + self.assertEqual( + self.discussion_1.attributes_definition[0]['string'], 'New Label', + msg='Should have updated the definition record') + + self.assertEqual(len(messages), 2) + + sql_properties_1 = self._get_sql_properties(messages[0]) + self.assertEqual( + sql_properties_1, + {'moderator_partner_id': self.partner.id, + 'discussion_color_code': 'purple'}) + sql_properties_2 = self._get_sql_properties(messages[1]) + self.assertEqual( + sql_properties_2, + {'state': 'draft'}) + + properties_values_1 = messages[0].attributes + properties_values_2 = messages[1].attributes + + self.assertEqual(len(properties_values_1), 2, msg='Discussion 1 has 2 properties') + self.assertEqual(len(properties_values_2), 1, msg='Discussion 2 has 1 property') + + self.assertEqual(properties_values_1[0]['value'], 'purple') + self.assertEqual(properties_values_1[1]['value'], self.partner.id) + self.assertEqual(properties_values_2[0]['value'], 'draft', + msg='Should have taken the default value') + + def test_properties_field_default(self): + message = self.env['test_new_api.message'].create({ + 'name': 'Test Message', + 'discussion': self.discussion_2.id, + 'author': self.user.id, + }) + self.assertEqual( + message.attributes[0]['value'], + 'draft', + msg='Should have taken the default value') + + message.attributes = [{'name': 'state', 'value': None}] + self.assertFalse( + message.attributes[0]['value'], + msg='Writing None should not reset to the default value') + + # test the case where the definition record come from a default as well + self.env['test_new_api.message']._fields['discussion'].default = lambda __: self.discussion_2.id + message = self.env['test_new_api.message'].create({ + 'name': 'Test Message', + 'author': self.user.id, + }) + self.assertEqual(message.discussion, self.discussion_2) + self.assertEqual( + message.attributes[0]['value'], + 'draft', + msg='Should have taken the default value') + + # the definition record come from a default value + self.discussion_2.attributes_definition = [{ + 'name': 'test', + 'type': 'char', + 'default': 'default char', + }] + message = self.env['test_new_api.message'] \ + .with_context(default_discussion=self.discussion_2) \ + .create({'name': 'Test Message', 'author': self.user.id}) + self.assertEqual(message.discussion, self.discussion_2) + self.assertEqual(message.attributes, [{ + 'name': 'test', + 'type': 'char', + 'default': 'default char', + 'value': 'default char', + }]) + + # test a default many2one + self.discussion_1.attributes_definition = [ + { + 'name': 'my_many2one', + 'string': 'Partner', + 'comodel': 'test_new_api.partner', + 'type': 'many2one', + # send the value like the web client does + 'default': [self.partner.id, 'Bob'], + }, + ] + sql_definition = self._get_sql_definition(self.discussion_1) + self.assertEqual(sql_definition[0]['default'], self.partner.id) + + read_values = self.discussion_1.read(['attributes_definition'])[0]['attributes_definition'] + self.assertEqual( + read_values[0]['default'], + (self.partner.id, self.partner.display_name), + msg='When reading many2one default, it should return the display name', + ) + + # read the default many2one and deactivate the name_get + read_values = self.discussion_1.read(['attributes_definition'], load=None)[0]['attributes_definition'] + self.assertEqual( + read_values[0]['default'], + self.partner.id, + msg='If the name_get is deactivate, it should not return the display name', + ) + + message = self.env['test_new_api.message'].create({ + 'name': 'Test Message', + 'author': self.user.id, + 'discussion': self.discussion_1.id, + }) + + properties = message.read(['attributes'])[0]['attributes'] + self.assertEqual(properties[0]['value'], (self.partner.id, self.partner.display_name)) + + self.assertEqual(message.attributes[0]['value'], self.partner.id) + + # give a default value and a value for a many2one + # the default value must be ignored + property_definition = self.discussion_1.read(['attributes_definition'])[0]['attributes_definition'] + property_definition[0]['value'] = (self.partner_2.id, 'Alice') + message = self.env['test_new_api.message'].create({ + 'name': 'Test Message', + 'author': self.user.id, + 'discussion': self.discussion_1.id, + 'attributes': property_definition, + }) + self.assertEqual( + message.attributes[0]['value'], + self.partner_2.id, + msg='Should not take the default value', + ) + + def test_properties_field_read(self): + """Test the behavior of the read method. + + In comparison with a simple "record.properties", the read method should not + record a recordset for the many2one, but a tuple with the record id and + the record name_get. + """ + properties_values = (self.message_1 | self.message_3).read(['attributes']) + + self.assertEqual(len(properties_values), 2) + + properties_message_1 = properties_values[0]['attributes'] + properties_message_3 = properties_values[1]['attributes'] + + self.assertTrue(isinstance(properties_message_1, list)) + self.assertTrue(isinstance(properties_message_3, list)) + + self.assertEqual(len(properties_message_1), 2, msg="Message 1 has 2 properties") + self.assertEqual(len(properties_message_3), 1, msg="Message 3 has 1 property") + + self.assertEqual( + properties_message_1[0]['name'], 'discussion_color_code', + msg='First message 1 property should be "discussion_color_code"') + self.assertEqual( + properties_message_1[1]['name'], 'moderator_partner_id', + msg='Second message 1 property should be "moderator_partner_id"') + self.assertEqual( + properties_message_3[0]['name'], 'state', + msg='First message 3 property should be "state"') + + many2one_property = properties_message_1[1] + self.assertEqual( + many2one_property['string'], 'Partner', + msg='Definition must be present when reading child') + self.assertEqual( + many2one_property['type'], 'many2one', + msg='Definition must be present when reading child') + self.assertEqual( + many2one_property['comodel'], 'test_new_api.partner', + msg='Definition must be present when reading child') + self.assertEqual(many2one_property['value'], (self.partner.id, self.partner.display_name)) + + # disable the name_get + properties_values = (self.message_1 | self.message_3).read(['attributes'], load=None) + many2one_property = properties_values[0]['attributes'][1] + + self.assertEqual( + many2one_property['value'], self.partner.id, + msg='If name_get is disable, should only return the record id') + + def test_properties_field_many2one_basic(self): + """Test the basic (read, write...) of the many2one property.""" + self.message_2.attributes = [ + { + "name": "discussion_color_code", + "type": "char", + "string": "Color Code", + "default": "blue", + "value": None, + }, { + "name": "moderator_partner_id", + "type": "many2one", + "string": "Partner", + "comodel": "test_new_api.partner", + "value": self.partner_2.id, + }, + ] + + self.assertFalse(self.message_2.attributes[0]['value']) + self.assertEqual(self.message_2.attributes[1]['value'], self.partner_2.id) + sql_values = self._get_sql_properties(self.message_2) + self.assertEqual( + sql_values, + {'moderator_partner_id': self.partner_2.id, + 'discussion_color_code': False}) + + # read the many2one + properties = self.message_2.read(['attributes'])[0]['attributes'] + self.assertEqual(properties[1]['value'], (self.partner_2.id, self.partner_2.display_name)) + self.assertEqual(properties[1]['comodel'], 'test_new_api.partner') + + @mute_logger('odoo.models.unlink') + def test_properties_field_many2one_unlink(self): + """Test the case where we unlink the many2one record.""" + self.message_2.attributes = [{ + 'name': 'moderator_partner_id', + 'value': self.partner.id, + }] + + # remove the partner on message 2 + self.partner.unlink() + with self.assertQueryCount(4): + # 1 query to read the field + # 1 query to read the definition + # 2 queries to check if the many2one still exists / name_get + self.assertFalse(self.message_2.attributes[0]['value']) + + # remove the partner, and use the read method + self.message_2.attributes = [{ + 'name': 'moderator_partner_id', + 'value': self.partner_2.id, + }] + self.partner_2.unlink() + + with self.assertQueryCount(4): + value = self.message_2.read(['attributes']) + value = value[0]['attributes'] + self.assertFalse(value[1]['value']) + self.assertEqual(value[1]['comodel'], 'test_new_api.partner') + + # many2one properties in a default value + partner = self.env['res.partner'].create({'name': 'test unlink'}) + self.message_2.attributes = [{ + 'name': 'moderator_partner_id', + 'type': 'many2one', + 'comodel': 'res.partner', + 'default': [partner.id, 'Bob'], + 'definition_changed': True, + }] + self.assertEqual( + self.message_2.read(['attributes'])[0]['attributes'], + [{ + 'name': 'moderator_partner_id', + 'type': 'many2one', + 'comodel': 'res.partner', + 'default': (partner.id, partner.display_name), + 'value': None, + }], + ) + partner.unlink() + self.assertEqual( + self.message_2.read(['attributes'])[0]['attributes'], + [{ + 'name': 'moderator_partner_id', + 'type': 'many2one', + 'comodel': 'res.partner', + 'default': None, + 'value': None, + }], + ) + + def test_properties_field_many2one_model_removed(self): + """Test the case where we uninstall a module, and the model does not exist anymore.""" + # simulate a module uninstall, the model is not available now + # when reading the model / many2one, it should return False + self.message_1.attributes = [{ + 'name': 'message', + 'value': self.message_3.id, + }] + + self.env.flush_all() + self.env.cr.execute( + """ + UPDATE test_new_api_discussion + SET attributes_definition = '[{"name": "message", "comodel": "wrong_model", "type": "many2one"}]' + WHERE id = %s + """, (self.discussion_1.id, ), + ) + self.env.invalidate_all() + + values = self.discussion_1.read(['attributes_definition'])[0] + self.assertFalse(values['attributes_definition'][0]['comodel']) + + attributes_definition = self.discussion_1.attributes_definition + self.assertEqual( + attributes_definition, + [{'name': 'message', 'comodel': False, 'type': 'many2one'}], + msg='The model does not exist anymore, it should return false', + ) + + # read the many2one on the child, should return False as well + self.assertFalse(self.message_1.attributes[0]['value']) + + values = self.message_1.read(['attributes'])[0]['attributes'] + self.assertEqual(values[0]['type'], 'many2one', msg='Property type should be preserved') + self.assertFalse(values[0]['value']) + self.assertFalse(values[0]['comodel']) + + sql_definition = self._get_sql_definition(self.discussion_1) + self.assertEqual( + sql_definition, + [{'name': 'message', 'comodel': 'wrong_model', 'type': 'many2one'}], + msg='Do not clean the definition until we write on the field' + ) + + # write on the properties definition must clean the wrong model name + self.discussion_1.attributes_definition = self.discussion_1.attributes_definition + + sql_definition = self._get_sql_definition(self.discussion_1) + self.assertEqual( + sql_definition, + [{'name': 'message', 'comodel': False, 'type': 'many2one'}], + msg='Should have cleaned the model key', + ) + + def test_properties_field_domain(self): + self.discussion_1.attributes_definition = [{ + 'name': 'message', + 'comodel': 'test_new_api.message', + 'type': 'many2one', + 'domain': "[('name', 'ilike', 'message')]", + }] + + domain = self.message_1.attributes[0]['domain'] + self.assertEqual(domain, "[('name', 'ilike', 'message')]") + + # set a wrong domain, it can happen if we uninstall a module + # and if a field defined in this module was used in the domain + self.env.flush_all() + new_properties = json.dumps([{ + 'name': 'message', + 'comodel': 'test_new_api.message', + 'type': 'many2one', + 'domain': "[('wrong_field', 'ilike', 'test')]", + }]) + self.env.cr.execute( + """ + UPDATE test_new_api_discussion + SET attributes_definition = %s + WHERE id = %s + """, (new_properties, self.discussion_1.id, ), + ) + self.env.flush_all() + self.env.invalidate_all() + + definition = self.discussion_1.read(['attributes_definition'])[0]['attributes_definition'] + self.assertNotIn('domain', definition) + + properties = self.message_1.read(['attributes'])[0]['attributes'] + self.assertNotIn('domain', properties) + + sql_definition = self._get_sql_definition(self.discussion_1) + self.assertIn( + 'domain', + sql_definition[0], + msg='The domain should remain in database until we write on the properties definition', + ) + + def test_properties_field_integer_float_boolean(self): + self.discussion_1.attributes_definition = [ + { + 'name': 'int_value', + 'string': 'Int Value', + 'type': 'integer', + }, { + 'name': 'float_value', + 'string': 'Float Value', + 'type': 'float', + }, { + 'name': 'boolean_value', + 'string': 'Boolean Value', + 'type': 'boolean', + }, + ] + + self.message_1.attributes = [{ + 'name': 'int_value', + 'value': 55555555555, + }, { + 'name': 'float_value', + 'value': 1.337, + }, { + 'name': 'boolean_value', + 'value': 77777, # should be converted into True + }] + + self.env.invalidate_all() + + self.assertEqual(len(self.message_1.attributes), 3) + self.assertEqual(self.message_1.attributes[0]['value'], 55555555555) + self.assertEqual(self.message_1.attributes[1]['value'], 1.337) + self.assertEqual(self.message_1.attributes[2]['value'], True) + + self.message_1.attributes = [{'name': 'boolean_value', 'value': 0}] + self.assertEqual( + self.message_1.attributes[2]['value'], False, + msg='Boolean value must have been converted to False') + + def test_properties_field_selection(self): + self.message_3.attributes = [{'name': 'state', 'value': 'done'}] + self.env.invalidate_all() + self.assertEqual(self.message_3.attributes[0]['value'], 'done') + + # the option might have been removed on the definition, write False + self.message_3.attributes = [{'name': 'state', 'value': 'unknown_selection'}] + self.env.invalidate_all() + self.assertFalse(self.message_3.attributes[0]['value']) + + with self.assertRaises(ValueError): + # check that 2 options can not have the same id + self.discussion_1.attributes_definition = [ + { + 'name': 'option', + 'type': 'selection', + 'selection': [['a', 'A'], ['b', 'B'], ['a', 'C']], + } + ] + + def test_properties_field_tags(self): + """Test the behavior of the tag property. + + The tags properties is basically the same as the selection property, + but you can select multiple values. It should work like the selection + (if we remove a value on the definition record, it should remove the value on each + child the next time we read, etc). + + Each tags has a color index defined on the definition record. + """ + self.discussion_1.attributes_definition = [ + { + 'name': 'my_tags', + 'string': 'My Tags', + 'type': 'tags', + 'tags': [ + ('be', 'BE', 1), + ('fr', 'FR', 2), + ('de', 'DE', 3), + ('it', 'IT', 1), + ], + 'default': ['be', 'de'], + }, + ] + message = self.env['test_new_api.message'].create( + {'discussion': self.discussion_1.id, 'author': self.user.id}) + + self.assertEqual(message.attributes[0]['value'], ['be', 'de']) + self.assertEqual(self._get_sql_properties(message), {'my_tags': ['be', 'de']}) + + self.env.invalidate_all() + + # remove the DE tags on the definition + self.discussion_1.attributes_definition = [ + { + 'name': 'my_tags', + 'string': 'My Tags', + 'type': 'tags', + 'tags': [ + ('be', 'BE', 1), + ('fr', 'FR', 2), + ('it', 'IT', 1), + ], + 'default': ['be', 'de'], + }, + ] + + # the value must remain in the database until the next write on the child + self.assertEqual(self._get_sql_properties(message), {'my_tags': ['be', 'de']}) + + self.assertEqual( + message.attributes[0]['value'], + ['be'], + msg='The tag has been removed on the definition, should be removed when reading the child') + self.assertEqual( + message.attributes[0]['tags'], + [['be', 'BE', 1], ['fr', 'FR', 2], ['it', 'IT', 1]]) + + # next write on the child must update the value + message.attributes = message.attributes + + self.assertEqual(self._get_sql_properties(message), {'my_tags': ['be']}) + + with self.assertRaises(ValueError): + # it should detect that the tag is duplicated + self.discussion_1.attributes_definition = [ + { + 'name': 'my_tags', + 'type': 'tags', + 'tags': [ + ('be', 'BE', 1), + ('be', 'FR', 2), + ], + }, + ] + + @mute_logger('odoo.models.unlink') + def test_properties_field_many2many_basic(self): + """Test the basic operation on a many2many properties (read, write...). + + Check also that if we remove some record, + those are filtered when we read the child. + """ + partners = self.env['test_new_api.partner'].create([ + {'name': f'Partner {i}'} + for i in range(20) + ]) + + self.discussion_1.attributes_definition = [{ + 'name': 'moderator_partner_ids', + 'string': 'Partners', + 'type': 'many2many', + 'comodel': 'test_new_api.partner', + }] + + with self.assertQueryCount(5): + self.message_1.attributes = [ + { + "name": "moderator_partner_ids", + "string": "Partners", + "type": "many2many", + "comodel": "test_new_api.partner", + "value": partners[:10].name_get(), + } + ] + self.assertEqual(self.message_1.attributes[0]['value'], partners[:10].ids) + + partners[:5].unlink() + with self.assertQueryCount(4): + self.assertEqual(self.message_1.attributes[0]['value'], partners[5:10].ids) + + partners[5].unlink() + with self.assertQueryCount(5): + properties = self.message_1.read(['attributes'])[0]['attributes'] + self.assertEqual(properties[0]['value'], partners[6:10].name_get()) + + # 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'] + + sql_values = self._get_sql_properties(self.message_1) + self.assertEqual(sql_values, {'moderator_partner_ids': partners[6:10].ids}) + + # read and disable name_get + properties = self.message_1.read(['attributes'], load=None)[0]['attributes'] + self.assertEqual( + properties[0]['value'], + partners[6:10].ids, + msg='Should not return the partners name', + ) + + # Check that duplicated ids are removed + self.env.flush_all() + moderator_partner_ids = partners[6:10].ids + moderator_partner_ids += moderator_partner_ids[2:] + new_value = json.dumps({"moderator_partner_ids": moderator_partner_ids}) + self.env.cr.execute( + """ + UPDATE test_new_api_message + SET attributes = %s + WHERE id = %s + """, (new_value, self.message_1.id, ), + ) + self.env.invalidate_all() + + properties = self.message_1.read(['attributes'], load=None)[0]['attributes'] + self.assertEqual( + properties[0]['value'], + partners[6:10].ids, + msg='Should removed duplicated ids', + ) + + # write a list with many2many values + self.message_1.attributes = [{ + 'name': 'partner_ids', + 'string': 'Partners', + 'type': 'many2many', + 'comodel': 'test_new_api.partner', + 'default': [(partners[8].id, 'Alice')], + 'value': [(partners[9].id, 'Bob')], + 'definition_changed': True, + }] + sql_properties = self._get_sql_properties(self.message_1) + self.assertEqual(sql_properties, {'partner_ids': [partners[9].id]}) + sql_definition = self._get_sql_definition(self.discussion_1) + self.assertEqual(sql_definition, [{ + 'name': 'partner_ids', + 'string': 'Partners', + 'type': 'many2many', + 'comodel': 'test_new_api.partner', + 'default': [partners[8].id], + }]) + + properties = self.message_1.read(['attributes'])[0]['attributes'] + self.assertEqual( + properties, + [{ + 'name': 'partner_ids', + 'string': 'Partners', + 'type': 'many2many', + 'comodel': 'test_new_api.partner', + 'default': [(partners[8].id, partners[8].display_name)], + 'value': [(partners[9].id, partners[9].display_name)], + }]) + + def test_properties_field_performance(self): + with self.assertQueryCount(4): + self.message_1.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) + self.message_1.attributes + + expected = [ + 'SELECT "test_new_api_emailmessage".id FROM "test_new_api_emailmessage" WHERE ("test_new_api_emailmessage"."message" in %s) ORDER BY "test_new_api_emailmessage"."id"', + 'UPDATE "test_new_api_message" SET "attributes" = %s, "write_date" = %s, "write_uid" = %s WHERE id IN %s', + ] + + with self.assertQueryCount(2), self.assertQueries(expected): + self.message_1.attributes = [ + { + "name": "discussion_color_code", + "type": "char", + "string": "Color Code", + "default": "blue", + "value": "red" + }, + { + "name": "moderator_partner_id", + "type": "many2one", + "string": "Partner", + "comodel": "test_new_api.partner", + "value": None + }, + ] + self.message_1.flush_recordset() + + def test_properties_field_change_definition(self): + """Test the behavior of the field when changing the definition.""" + + attributes_definition = self.discussion_1.attributes_definition + self.message_1.attributes = [ + { + "name": "discussion_color_code", + "value": None, + }, + { + "name": "moderator_partner_id", + "value": None, + } + ] + self.env.invalidate_all() + self.assertFalse(self.message_1.attributes[0]['value']) + + # add a property on the definition record + attributes_definition += [{'name': 'state', 'string': 'State', 'type': 'char'}] + self.discussion_1.attributes_definition = attributes_definition + self.message_1.attributes = [{'name': 'state', 'value': 'ready'}] + + self.env.invalidate_all() + + self.assertEqual(self.message_1.attributes[2]['value'], 'ready') + + # remove a property from the definition + # the properties on the child should remain, until we write on it + # when reading, the removed property must be filtered + self.discussion_1.attributes_definition = attributes_definition[:-1] # remove the state field + + self.assertFalse(self.message_1.attributes[0]['value']) + + value = self._get_sql_properties(self.message_1) + self.assertEqual(value.get('state'), 'ready', msg='The field should be in database') + + self.message_1.attributes = [{'name': 'name', 'value': 'Test name'}] + value = self._get_sql_properties(self.message_1) + self.assertFalse( + value.get('state'), + msg='After updating an other property, the value must be cleaned') + + # check that we can only set a allowed list of properties type + with self.assertRaises(ValueError): + self.discussion_1.attributes_definition = [{'name': 'state', 'type': 'wrong_type'}] + + # check the property ID unicity + with self.assertRaises(ValueError): + self.discussion_1.attributes_definition = [ + {'name': 'state', 'type': 'char'}, + {'name': 'state', 'type': 'datetime'}, + ] + + def test_properties_field_onchange(self): + """If we change the definition record, the onchange of the properties field must be triggered.""" + message_form = Form(self.env['test_new_api.message']) + + with self.assertQueryCount(11): + message_form.discussion = self.discussion_1 + message_form.author = self.user + + self.assertEqual( + message_form.attributes, + [{ + 'name': 'discussion_color_code', + 'string': 'Color Code', + 'type': 'char', + 'default': 'blue', + 'value': 'blue', + }, { + 'name': 'moderator_partner_id', + 'string': 'Partner', + 'type': 'many2one', + 'comodel': 'test_new_api.partner', + 'value': None, + }], + msg='Should take the new definition when changing the definition record', + ) + + # change the discussion field + message_form.discussion = self.discussion_2 + + properties = message_form.attributes + + self.assertEqual(len(properties), 1) + self.assertEqual( + properties[0]['name'], + 'state', + msg='Should take the values of the new definition record', + ) + + with self.assertQueryCount(7): + message = message_form.save() + + self.assertEqual( + message.attributes[0]['value'], + 'draft', + msg='Should take the default value', + ) + + # check cached value + cached_value = self.env.cache.get(message, message._fields['attributes']) + self.assertEqual(cached_value, {'state': 'draft'}) + + # change the definition record, change the definition and add default values + self.assertEqual(message.discussion, self.discussion_2) + with self.assertQueryCount(7): + message.discussion = self.discussion_1 + self.assertEqual( + self.discussion_1.attributes_definition, + [{ + 'name': 'discussion_color_code', + 'type': 'char', + 'string': 'Color Code', + 'default': 'blue', + }, { + 'name': 'moderator_partner_id', + 'type': 'many2one', + 'string': 'Partner', + 'comodel': 'test_new_api.partner', + }], + ) + self.assertEqual( + message.attributes, + [{ + 'name': 'discussion_color_code', + 'type': 'char', + 'string': 'Color Code', + 'default': 'blue', + 'value': 'blue', + }, { + 'name': 'moderator_partner_id', + 'type': 'many2one', + 'string': 'Partner', + 'comodel': 'test_new_api.partner', + 'value': None, + }], + ) + + self.discussion_1.attributes_definition = False + self.discussion_2.attributes_definition = [{ + 'name': 'test', + 'type': 'char', + 'default': 'Default', + }] + + # change the message discussion to remove the properties + # discussion 1 -> discussion 2 + message.discussion = self.discussion_2 + message.attributes = [{'name': 'test', 'value': 'Test'}] + onchange_values = message.onchange( + values={ + 'discussion': self.discussion_1.id, + 'attributes': [{ + 'name': 'test', + 'type': 'char', + 'default': 'Default', + 'value': 'Test', + }], + }, + field_name=['discussion'], + field_onchange={'attributes': '1'}, + ) + self.assertTrue( + 'attributes' in onchange_values['value'], + msg='Should have detected the definition record change') + self.assertEqual( + onchange_values['value']['attributes'], [], + msg='Should have reset the properties definition') + + # change the message discussion to add new properties + # discussion 2 -> discussion 1 + message.discussion = self.discussion_1 + onchange_values = message.onchange( + values={ + 'discussion': self.discussion_2.id, + 'attributes': [], + }, + field_name=['discussion'], + field_onchange={'attributes': '1'}, + ) + self.assertTrue( + 'attributes' in onchange_values['value'], + msg='Should have detected the definition record change') + self.assertEqual( + onchange_values['value']['attributes'], + [{'name': 'test', 'type': 'char', 'default': 'Default', 'value': 'Default'}], + msg='Should have reset the properties definition to the discussion 1 definition') + + # change the definition record and the definition at the same time + message_form = Form(message) + message_form.discussion = self.discussion_2 + message_form.attributes = [{ + 'name': 'new_property', + 'type': 'char', + 'value': 'test value', + 'definition_changed': True, + }] + message = message_form.save() + self.assertEqual( + self.discussion_2.attributes_definition, + [{'name': 'new_property', 'type': 'char'}]) + self.assertEqual( + message.attributes, + [{'name': 'new_property', 'type': 'char', 'value': 'test value'}]) + + def test_properties_field_definition_update(self): + """Test the definition update from the child.""" + self.discussion_1.attributes_definition = [] + + self.message_1.attributes = [ + { + 'name': 'my_many2one', + 'string': 'Partner', + 'comodel': 'test_new_api.partner', + 'type': 'many2one', + # send the value like the web client does + 'default': [self.partner.id, 'Bob'], + 'value': [self.partner_2.id, "Test"], + }, { + 'name': 'my_many2many', + 'string': 'Partner', + 'comodel': 'test_new_api.partner', + 'type': 'many2many', + # send the value like the web client does + 'default': [[self.partner.id, 'Bob'], [self.partner_2.id, 'Test']], + 'value': [[self.partner_2.id, "Test"]], + 'definition_changed': True, + }, + ] + self.env.invalidate_all() + + sql_definition = self._get_sql_definition(self.discussion_1) + expected_definition = [ + { + 'name': 'my_many2one', + 'string': 'Partner', + 'comodel': 'test_new_api.partner', + 'type': 'many2one', + 'default': self.partner.id, + }, { + 'name': 'my_many2many', + 'string': 'Partner', + 'comodel': 'test_new_api.partner', + 'type': 'many2many', + 'default': [self.partner.id, self.partner_2.id], + }, + ] + self.assertEqual(sql_definition, expected_definition) + + sql_properties = self._get_sql_properties(self.message_1) + expected_properties = { + 'my_many2one': self.partner_2.id, + 'my_many2many': [self.partner_2.id], + } + self.assertEqual(expected_properties, sql_properties) + + def test_properties_field_security(self): + """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('') + + # 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'}) + self.message_1.attributes = [{ + 'name': 'test', + 'type': 'many2one', + 'comodel': 'test_new_api.multi.tag', + 'value': [tag.id, 'Tag'], + 'definition_changed': True, + }] + values = self.message_1.read(['attributes'])[0]['attributes'][0] + self.assertEqual(values['value'], (tag.id, 'Test Tag')) + self.env.invalidate_all() + with patch.object(MultiTag, 'check_access_rights', side_effect=_mocked_check_access_rights): + values = self.message_1.read(['attributes'])[0]['attributes'][0] + self.assertEqual(values['value'], (tag.id, 'No Access')) + + def _get_sql_properties(self, message): + self.env.flush_all() + + self.env.cr.execute( + """ + SELECT attributes + FROM test_new_api_message + WHERE id = %s + """, (message.id, ), + ) + value = self.env.cr.fetchone() + self.assertTrue(value and value[0]) + return value[0] + + def _get_sql_definition(self, discussion): + self.env.flush_all() + + self.env.cr.execute( + """ + SELECT attributes_definition + FROM test_new_api_discussion + WHERE id = %s + """, (discussion.id, ), + ) + value = self.env.cr.fetchone() + self.assertTrue(value and value[0]) + return value[0] diff --git a/odoo/addons/test_new_api/views/test_new_api_views.xml b/odoo/addons/test_new_api/views/test_new_api_views.xml index 36ee073f0c3..52d2f3ac95c 100644 --- a/odoo/addons/test_new_api/views/test_new_api_views.xml +++ b/odoo/addons/test_new_api/views/test_new_api_views.xml @@ -214,6 +214,7 @@ +