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 @@
+
+
+
+
+
+
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"
+ );
+ });
+});