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..d53e2b72a7d --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.js @@ -0,0 +1,370 @@ +/** @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 { 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: "", + resModelId: 0, + 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) { + if (!newModel || !newModel.length || !newModel[0].id) { + // remove the model + const propertyDefinition = { + ...this.state.propertyDefinition, + comodel: false, + default: false, + domain: false, + value: false, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + this.state.resModel = ''; + this.state.resModelId = 0; + this.state.resModelDescription = ''; + await this._updateMatchingRecordsCount(); + return; + } + const newModelId = newModel[0].id; + + // if we change the model, we should reset the default value and the domain + const modelChanged = newModelId !== this.state.resModelId; + + this.state.resModelId = newModelId; + + const result = await this.orm.call( + 'ir.model', + 'read', + [[newModelId], ['model', 'name']], + ); + + if (!result || !result.length) { + return; + } + this.state.resModelDescription = result[0].name; + const propertyDefinition = { + ...this.state.propertyDefinition, + 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 currentModel = this.state.resModel; + const newModel = propertyDefinition.comodel; + + this.state.propertyDefinition = propertyDefinition; + 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", + "search_read", + [ + [['model', '=', newModel]], + ['id', 'name'], + ], + ); + if (!result || !result.length) { + return; + } + this.state.resModelId = result[0].id; + this.state.resModelDescription = result[0].name; + } catch (_) { + // can not read the ir.model + this.state.resModelId = 0; + this.state.resModelDescription = sprintf( + _lt('You do not have access to the model "%s".'), + newModel + ); + } + + await this._updateMatchingRecordsCount(); + } else if (!newModel) { + this.state.resModelId = false; + this.state.resModelDescription = ""; + } + } + + /** + * Update the number of records that match the current domain. + */ + async _updateMatchingRecordsCount() { + if (this.state.resModelId && this.state.propertyDefinition.comodel) { + 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, + 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..4939ea07472 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.xml @@ -0,0 +1,176 @@ + + + +
+
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 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/views/fields/properties_field_tests.js b/addons/web/static/tests/views/fields/properties_field_tests.js new file mode 100644 index 00000000000..3484ae5a4be --- /dev/null +++ b/addons/web/static/tests/views/fields/properties_field_tests.js @@ -0,0 +1,1100 @@ +/** @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 === "name_search" && model === "ir.model") { + return [[1337, "Partner"], [1338, "User"]]; + } else if (method === "read" && model === "ir.model") { + return [{"id": 1338, "model": "res.users", "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 + */ + QUnit.test("properties: many2many", async function (assert) { + async function mockRPC(route, { method, model, args, kwargs }) { + if (method === "check_access_rights") { + return true; + } else if (method === "name_search" && model === "ir.model") { + return [[1337, "Partner"], [1338, "User"]]; + } else if (method === "read" && model === "ir.model") { + return [{"id": 1338, "model": "res.users", "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 === "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" + ); + }); +});